This project analyses a dataset of over 6.3 million mobile-money payment transactions to detect and understand fraudulent activity using Oracle SQL. Starting from a raw CSV loaded via an external table, the analysis works through data quality checks, then investigates where fraud happens, how much money is involved, when it occurs, and how effective the existing fraud-detection flag is.
The raw CSV is exposed through an Oracle external table, then materialised into a physical table for faster querying. Before any analysis, the data is checked for volume, the overall fraud rate, transaction mix, and duplicates.
CREATE TABLE payment_transactions_ext ( step NUMBER, txn_type VARCHAR2(20), amount NUMBER(15, 2), name_orig VARCHAR2(50), oldbalance_org NUMBER(15, 2), newbalance_orig NUMBER(15, 2), name_dest VARCHAR2(50), oldbalance_dest NUMBER(15, 2), newbalance_dest NUMBER(15, 2), is_fraud NUMBER(1), is_flagged_fraud NUMBER(1) ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY data_dir ACCESS PARAMETERS ( RECORDS DELIMITED BY '\n' SKIP 1 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ) LOCATION ('fraud_detection.csv') ) REJECT LIMIT UNLIMITED; -- Materialise into a physical table for performance CREATE TABLE payments_transactions AS SELECT * FROM payment_transactions_ext;
-- Total transactions SELECT count(*) AS Total_Transactions FROM payments_transactions; -- Percentage of fraudulent transactions (0.012%) SELECT ROUND( (SELECT count(*) FROM payments_transactions WHERE is_fraud <> 0) / (SELECT count(*) FROM payments_transactions), 5) AS Fraud_Percentage FROM dual;
-- How many transactions per type SELECT txn_type, count(txn_type) AS Nr_of_transactions FROM payments_transactions GROUP BY txn_type ORDER BY 2 DESC;

-- Duplicate check — returns no rows, confirming clean data SELECT step, txn_type, amount, name_orig, oldbalance_org, newbalance_orig, name_dest, oldbalance_dest, newbalance_dest, is_fraud, is_flagged_fraud FROM payments_transactions GROUP BY step, txn_type, amount, name_orig, oldbalance_org, newbalance_orig, name_dest, oldbalance_dest, newbalance_dest, is_fraud, is_flagged_fraud HAVING count(*) > 1;

Breaking fraud down by transaction type reveals which channels are targeted. For each type the query computes the transaction count, the number and rate of frauds, and the single biggest fraudulent amount.
SELECT txn_type, TO_CHAR(count(txn_type),'FM9,999,999') AS Nr_of_transactions, (SELECT TO_CHAR(count(txn_type), 'FM9,999,999') FROM payments_transactions f WHERE f.is_fraud > 0 AND f.txn_type = p.txn_type) AS Fraud_Transactions, ROUND((SELECT count(txn_type) FROM payments_transactions f WHERE f.is_fraud > 0 AND f.txn_type = p.txn_type) / count(txn_type), 4) AS Fraud_Rate, (SELECT TO_CHAR(max(amount),'FM9,999,999,999') FROM payments_transactions g WHERE g.is_fraud > 0 AND g.txn_type = p.txn_type) AS Biggest_Fraud FROM payments_transactions p GROUP BY txn_type ORDER BY 3 DESC;

-- Total money moved vs total lost to fraud SELECT (SELECT TO_CHAR(SUM(amount), 'FM9,999,999,999,999') FROM payments_transactions) AS Total_Amount, (SELECT TO_CHAR(SUM(amount), 'FM9,999,999,999,999') FROM payments_transactions WHERE is_fraud <> 0) AS Fraud_Amount FROM dual;

-- Average amount: overall vs legitimate vs fraudulent SELECT (SELECT TO_CHAR(avg(amount), 'FM9,999,999,999') FROM payments_transactions) AS Total_Avg, (SELECT TO_CHAR(avg(amount), 'FM9,999,999,999') FROM payments_transactions WHERE is_fraud = 0) AS Legitimate_Avg, (SELECT TO_CHAR(avg(amount), 'FM9,999,999,999') FROM payments_transactions WHERE is_fraud <> 0) AS Fraud_Avg FROM dual;

-- Do larger transactions have a higher fraud rate? SELECT amount_bucket, count(*) AS number_of_transactions, SUM(is_fraud) AS fraudulent_transactions FROM ( SELECT is_fraud, CASE WHEN amount > 200000 THEN 'large tx' WHEN amount > 100000 THEN 'medium tx' ELSE 'small tx' END AS amount_bucket FROM payments_transactions ) GROUP BY amount_bucket;

The step column counts hours since the start of the simulation. Using MOD(step, 24) maps each transaction to an hour of the day to reveal volume and fraud patterns across a 24-hour cycle.
-- Transaction volume and fraud count by hour of day SELECT count(*) AS Transaction_volume, TO_CHAR(MOD(step,24) || ':00') AS Time_of_day, SUM(is_fraud) AS Fraudulent_transactions FROM payments_transactions GROUP BY MOD(step,24) ORDER BY 1 DESC;

-- Do frauds often drain the account to zero? SELECT (SELECT count(*) FROM payments_transactions WHERE is_fraud = 1) AS Fraudulent_transactions, (SELECT count(*) FROM payments_transactions WHERE is_fraud = 1 AND newbalance_orig = 0) AS Drained_to_zero FROM dual;

-- Balance inconsistencies: sender balance doesn't drop by the amount sent SELECT -amount AS "Transaction Amount", newbalance_orig - oldbalance_org AS "Transaction Difference" FROM payments_transactions WHERE newbalance_orig - oldbalance_org <> -amount;

-- How effective is the existing fraud flag? SELECT (SELECT count(is_fraud) FROM payments_transactions WHERE is_fraud <> 0) AS "Fraudulent Transactions", (SELECT count(is_flagged_fraud) FROM payments_transactions WHERE is_flagged_fraud <> 0) AS "Flagged Fraudulent Transactions", ROUND((SELECT count(is_flagged_fraud) FROM payments_transactions WHERE is_flagged_fraud <> 0) / (SELECT count(is_fraud) FROM payments_transactions WHERE is_fraud <> 0), 5) AS "Flag Success Rate" FROM dual;